Skip to content

feat: expose and bulk-update subscription group memberships - #80

Closed
kapdon wants to merge 5 commits into
TypeType-Video:devfrom
kapdon:feat/subscription-group-memberships
Closed

feat: expose and bulk-update subscription group memberships#80
kapdon wants to merge 5 commits into
TypeType-Video:devfrom
kapdon:feat/subscription-group-memberships

Conversation

@kapdon

@kapdon kapdon commented Aug 26, 2026

Copy link
Copy Markdown

Summary

Adds the follow-up Server contract needed for efficient subscription-group organization in TypeType-Video/TypeType#172.

  • adds authenticated GET /subscriptions/group-memberships;
  • returns each current subscription once with its existing channel fields and complete, sorted groupIds;
  • returns groupIds: [] for ungrouped subscriptions;
  • extends PUT /subscriptions/groups/{groupId}/channels with a bounded channelUrls array;
  • extends DELETE /subscriptions/groups/{groupId}/channels with singular or bounded batch JSON bodies;
  • preserves the shipped singular PUT body and DELETE ?url= contract;
  • performs batch writes in one account-scoped transaction under the existing subscription mutation lock;
  • canonicalizes and deduplicates requested channel URLs;
  • rejects an entire batch addition before writing if any channel is not a current subscription;
  • treats absent memberships as success for batch deletion, making retries idempotent.

Why a separate membership projection

The frontend needs a channel-centric view to render each subscription with all of its group assignments. Reusing SubscriptionItem would add group queries and fields to unrelated feed, backup, import, RSS, and recommendation paths. Fetching every group separately would instead require N requests for N groups.

This endpoint keeps the shared model unchanged and provides the full account-scoped projection in one request. It is intentionally unpaginated, matching the existing full subscription snapshot used by clients; search and filtering can remain local over the cached data.

API changes

  • GET /subscriptions/group-memberships
  • PUT /subscriptions/groups/{groupId}/channels
    • existing: { "channelUrl": "..." }
    • new: { "channelUrls": ["...", "..."] }, 1 to 500 entries
  • DELETE /subscriptions/groups/{groupId}/channels
    • existing: ?url=...
    • new singular body: { "channelUrl": "..." }
    • new batch body: { "channelUrls": ["...", "..."] }, 1 to 500 entries

The handwritten OpenAPI contract documents the projection and both singular and batch mutation forms.

Commit structure

Commit Insertions Purpose
2e93c961 133 account-scoped membership projection
a3a1530a 156 bounded atomic bulk membership mutations

Both commits stay below the requested 290-insertion review limit.

Verification

Exact head: a3a1530a92075ca8ad377fc2fad1da6ed58800ef

Using the required JDK 25 toolchain:

./gradlew --no-daemon clean check shadowJar validateOpenApi --console=plain
BUILD SUCCESSFUL
1,134 tests, 0 failures, 0 errors, 0 skipped

Live HTTP QA against the production shadow JAR with disposable PostgreSQL and Dragonfly verified:

  • authentication and cross-account isolation;
  • subscription data with empty, single-group, and multi-group groupIds;
  • existing singular PUT and DELETE compatibility;
  • bulk add, exact retry, canonicalization, and deduplication;
  • acceptance of 500 entries and rejection of 501;
  • atomic rejection when one requested channel is not subscribed;
  • idempotent bulk deletion including absent memberships;
  • cleanup of all disposable processes, containers, volumes, networks, and ports.

Component follow-up

A separate TypeType-Frontend PR will consume this projection for group management and bulk channel assignment. No Token, Downloader, or Player change is required.

Add an account-scoped read model for subscription channels and their
complete group assignments so the frontend can render and edit memberships
without issuing one filtered subscription request per group.

Constraint: Keep the shared SubscriptionItem unchanged because feeds, backups, imports, RSS, and recommendations reuse it
Rejected: Add groupIds to SubscriptionItem | would query and serialize group data in unrelated paths
Rejected: Fetch each group projection from the frontend | creates N requests for N groups
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep groupIds account-scoped and include empty arrays for ungrouped subscriptions
Tested: JDK 25 clean check, 1,132 tests, shadowJar, OpenAPI validation, and live HTTP QA
Not-tested: Frontend integration is intentionally deferred
Let clients add or remove many subscribed channels from one group in a
single account-scoped transaction while preserving the shipped singular
request forms.

Constraint: The singular membership contract shipped in v1.6.0 and must remain compatible
Rejected: Cross-group membership delta endpoint | bulk organization is naturally scoped to one group
Rejected: Repeated delete query parameters | encoded channel URLs can exceed practical URL limits
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep batch writes bounded, atomic, idempotent, and protected by SubscriptionMutationLock
Tested: JDK 25 clean check, 1,134 tests, coverage, shadowJar, OpenAPI validation, focused retry regression, and live HTTP QA
Not-tested: Frontend and Android integration are intentionally deferred
Return a client error when a DELETE supplies both the legacy URL query
parameter and a JSON membership body so the server cannot silently apply
only part of the requested mutation.

Constraint: Preserve both shipped query-only deletion and the new body-only batch contract
Rejected: Give the query parameter precedence | silently ignores a valid batch body
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the two DELETE input forms mutually exclusive at the HTTP boundary
Tested: Red-green route regression, JDK 25 check with 1,135 tests, coverage, shadowJar, OpenAPI validation, and live HTTP QA
Not-tested: Frontend integration remains intentionally deferred
@kapdon

kapdon commented Aug 26, 2026

Copy link
Copy Markdown
Author

would you preferr over-load extended for singleton and arrays or do we prefer
/channel = single and /channels = array?

Reject oversized membership bodies before they can be fully buffered and
validate each submitted channel URL at the API boundary. Treat any non-empty
DELETE body as present so whitespace cannot bypass query/body exclusivity.

Constraint: Preserve the existing singular and batch membership contracts
Rejected: Rely on Content-Length alone | chunked requests can omit the header
Confidence: high
Scope-risk: narrow
Directive: Keep request limits aligned with the OpenAPI membership schemas
Tested: ./gradlew --no-daemon clean check shadowJar validateOpenApi
Tested: Live HTTP checks for body limits, URL length, and DELETE ambiguity
@kapdon
kapdon marked this pull request as ready for review August 26, 2026 03:42

@Priveetee Priveetee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @kapdon, thx again for pushing this forward!

I reviewed the current head locally. The API shape looks good to me: keeping the existing endpoint while adding the batch form preserves compatibility, and the account scoping, canonicalization, deduplication and transaction handling look solid. The focused tests and full checks pass locally.

I’m not ready to merge this yet because I found one regression:

SubscriptionsService.getAll() now repairs avatars before applying the subscription filter. Previously, filtered requests repaired only the selected subscriptions. With the 25-avatar repair limit, /subscriptions?groupId=... or ungrouped=true can spend the repair budget on unrelated channels, leaving the selected channels with empty avatars. It also updates unrelated subscriptions during a filtered read.

Could u move the avatar repair after the selection filter and add a regression test for this case?

One more consistency concern: getAllWithGroupMemberships() reads memberships and subscriptions without the existing subscription mutation lock. A concurrent group update could make the response contain stale or incomplete groupIds. Please either protect this projection with the same lock or use an explicit consistent snapshot.

For your endpoint question, I’d keep the current overloaded endpoint rather than split it into /channel and /channels. It preserves existing clients and keeps the operation type explicit in the request body.

Thx again, this is close. I’d be happy to review the next revision.

Repair avatars only after applying subscription selection so unrelated
channels neither consume the repair budget nor receive database writes.
Serialize membership projections with account subscription mutations so
channel data and group assignments come from one coherent state.

Constraint: Preserve avatar repair for unfiltered and membership projection responses
Rejected: Repair all subscriptions before filtering | unrelated rows consume the repair limit and receive writes
Rejected: Add a new snapshot transaction API | the existing per-user mutation lock already serializes group changes
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep membership projections and mutations on SubscriptionMutationLock
Tested: JDK 25 clean check, 1,141 tests, shadowJar, OpenAPI validation, and live HTTP lock-contention QA
@kapdon
kapdon requested a review from Priveetee August 26, 2026 20:06
@Priveetee Priveetee closed this Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants